/*  SPDX-License-Identifier: GPL-2.0-or-later */
/*!********************************************************************

  Audacity: A Digital Audio Editor

  CloudProjectSnapshot.cpp

  Dmitry Vedenko

**********************************************************************/
#include "LocalProjectSnapshot.h"

#include <algorithm>
#include <future>

#include "../OAuthService.h"
#include "../ServiceConfig.h"

#include "BasicUI.h"

#include "BlockHasher.h"
#include "CloudProjectsDatabase.h"
#include "DataUploader.h"
#include "MixdownUploader.h"
#include "NetworkUtils.h"
#include "ProjectCloudExtension.h"

#include "ExportUtils.h"
#include "MemoryX.h"
#include "Project.h"
#include "SampleBlock.h"
#include "Sequence.h"
#include "Track.h"
#include "WaveClip.h"
#include "WaveTrack.h"
#include "WaveTrackUtilities.h"

#include "IResponse.h"
#include "NetworkManager.h"
#include "Request.h"

#include "MissingBlocksUploader.h"

#include "StringUtils.h"

#include "crypto/SHA256.h"

namespace audacity::cloud::audiocom::sync {
struct LocalProjectSnapshot::ProjectBlocksLock final : private BlockHashCache
{
    ProjectCloudExtension& Extension;

    SampleBlockIDSet BlockIds;

    std::vector<LockedBlock> Blocks;
    std::vector<BlockUploadTask> MissingBlocks;

    std::unordered_map<int64_t, size_t> BlockIdToIndex;
    std::unordered_map<std::string, size_t> BlockHashToIndex;

    std::unique_ptr<BlockHasher> Hasher;

    std::future<void> UpdateCacheFuture;
    std::vector<std::pair<int64_t, std::string> > NewHashes;

    std::function<void()> OnBlocksLocked;

    explicit ProjectBlocksLock(
        ProjectCloudExtension& extension, AudacityProject& project,
        std::function<void()> onBlocksLocked)
        : Extension{extension}
        , OnBlocksLocked{std::move(onBlocksLocked)}
    {
        VisitBlocks(TrackList::Get(project));

        if (Extension.IsCloudProject()) {
            CloudProjectsDatabase::Get().UpdateProjectBlockList(
                Extension.GetCloudProjectId(), BlockIds);
        }

        Hasher = std::make_unique<BlockHasher>();

        Hasher->ComputeHashes(*this, Blocks, [this] { CollectHashes(); });
    }

    ~ProjectBlocksLock() override
    {
    }

    void VisitBlocks(TrackList& tracks)
    {
        const auto visitor = [this](const SampleBlockPtr& pBlock){
            const auto id = pBlock->GetBlockID();
            if (id >= 0) {
                Blocks.push_back({
                        id, pBlock->GetSampleFormat(), pBlock });
                BlockIdToIndex[id] = Blocks.size() - 1;
            }
            //Do not compute hashes for negative id's, which describe
            //the length of a silenced sequence. Making an id record in
            //project blob is enough to restore block contents fully.
            //VS: Older versions of Audacity encoded them as a regular
            //blocks, but due to wrong sanity checks in `WavPackCompressor`
            //attempt to load them could fail. The check above will purge
            //such blocks if they are present in old project.
        };
        WaveTrackUtilities::VisitBlocks(tracks, visitor, &BlockIds);
    }

    void CollectHashes()
    {
        // TakeResult() will call UpdateHash() for each block
        // not found in the cache
        const auto result = Hasher->TakeResult();

        for (auto [id, hash] : result) {
            auto it = BlockIdToIndex.find(id);

            if (it == BlockIdToIndex.end()) {
                assert(false);
                continue;
            }

            while (BlockHashToIndex.find(hash) != BlockHashToIndex.end())
            {
                // Hash is used by another block, rehash
                hash = crypto::sha256(hash);
            }

            BlockHashToIndex[hash]  = BlockIdToIndex[id];
            Blocks[it->second].Hash = std::move(hash);
        }

        // This will potentially block, if the cache is being updated
        // already
        UpdateProjectHashesInCache();

        if (OnBlocksLocked) {
            OnBlocksLocked();
        }
    }

    void UpdateProjectHashesInCache()
    {
        if (!Extension.IsCloudProject()) {
            return;
        }

        UpdateCacheFuture = std::async(
            std::launch::async,
            [this, hashes = std::move(NewHashes)]
        {
            CloudProjectsDatabase::Get().UpdateBlockHashes(
                Extension.GetCloudProjectId(), hashes);
        });
    }

    bool GetHash(int64_t blockId, std::string& hash) const override
    {
        if (!Extension.IsCloudProject()) {
            return false;
        }

        auto cachedResult = CloudProjectsDatabase::Get().GetBlockHash(
            Extension.GetCloudProjectId(), blockId);

        if (!cachedResult) {
            return false;
        }

        hash = std::move(*cachedResult);

        return true;
    }

    void UpdateHash(int64_t blockId, const std::string& hash) override
    {
        NewHashes.emplace_back(blockId, hash);
    }

    void FillMissingBlocks(const std::vector<UploadUrls>& missingBlockUrls)
    {
        for (const auto& urls : missingBlockUrls) {
            auto it = BlockHashToIndex.find(ToUpper(urls.Id));

            if (it == BlockHashToIndex.end()) {
                assert(false);
                continue;
            }

            const auto index = it->second;

            MissingBlocks.push_back(BlockUploadTask { urls, Blocks[index] });
        }
    }
};

LocalProjectSnapshot::LocalProjectSnapshot(
    Tag, const ServiceConfig& config, const OAuthService& oauthService,
    ProjectCloudExtension& extension, std::string name, UploadMode mode,
    AudiocomTrace trace)
    : mProjectCloudExtension{extension}
    , mWeakProject{extension.GetProject()}
    , mServiceConfig{config}
    , mOAuthService{oauthService}
    , mAudiocomTrace{trace}
    , mProjectName{std::move(name)}
    , mUploadMode{mode}
    , mCancellationContext{concurrency::CancellationContext::Create()}
{
}

LocalProjectSnapshot::~LocalProjectSnapshot()
{
}

LocalProjectSnapshot::Future LocalProjectSnapshot::Create(
    const ServiceConfig& config, const OAuthService& oauthService,
    ProjectCloudExtension& extension, std::string name, UploadMode mode,
    AudiocomTrace trace)
{
    auto project = extension.GetProject().lock();

    if (!project) {
        return {}
    }

    auto snapshot = std::make_shared<LocalProjectSnapshot>(
        Tag {}, config, oauthService, extension, std::move(name), mode, trace);

    snapshot->mProjectCloudExtension.OnUploadOperationCreated(snapshot);

    snapshot->mProjectBlocksLock = std::make_unique<ProjectBlocksLock>(
        extension, *project,
        [weakSnapshot = std::weak_ptr(snapshot)]
    {
        auto snapshot = weakSnapshot.lock();

        if (snapshot == nullptr) {
            return;
        }

        auto project = snapshot->GetProject();

        if (project == nullptr) {
            return;
        }

        snapshot->mProjectCloudExtension.OnBlocksHashed(*snapshot);
    });

    return snapshot->mCreateSnapshotPromise.get_future();
}

bool LocalProjectSnapshot::IsCompleted() const
{
    return mCompleted.load(std::memory_order_acquire);
}

std::shared_ptr<AudacityProject> LocalProjectSnapshot::GetProject()
{
    return mWeakProject.lock();
}

void LocalProjectSnapshot::Start()
{
    UpdateProjectSnapshot();
}

void LocalProjectSnapshot::SetUploadData(const ProjectUploadData& data)
{
    mProjectDataReady.store(true);
    mProjectDataPromise.set_value(data);
}

void LocalProjectSnapshot::Cancel()
{
    mCancelled.store(true, std::memory_order_release);

    mCancellationContext->Cancel();

    if (!mProjectDataReady.load(std::memory_order_acquire)) {
        mProjectDataPromise.set_value({});
    }

    UploadFailed({ CloudSyncError::Cancelled });
}

void LocalProjectSnapshot::Abort()
{
    mCancelled.store(true, std::memory_order_release);

    mCancellationContext->Cancel();

    if (!mProjectDataReady.load(std::memory_order_acquire)) {
        mProjectDataPromise.set_value({});
    }

    UploadFailed({ CloudSyncError::Aborted });

    DeleteSnapshot();
}

void LocalProjectSnapshot::UploadFailed(CloudSyncError error)
{
    if (!mCompleted.exchange(true, std::memory_order_release)) {
        mProjectCloudExtension.OnSyncCompleted(
            this, std::make_optional(error), mAudiocomTrace);
    }
}

void LocalProjectSnapshot::DataUploadFailed(const ResponseResult& uploadResult)
{
    UploadFailed({ DeduceError(uploadResult.Code), uploadResult.Content });
}

void LocalProjectSnapshot::DataUploadFailed(
    const MissingBlocksUploadProgress& uploadResult)
{
    CloudSyncError::ErrorType errorType = CloudSyncError::DataUploadFailed;

    for (const auto& uploadError : uploadResult.UploadErrors) {
        if (
            uploadError.Code == SyncResultCode::Success
            || uploadError.Code == SyncResultCode::Conflict) {
            continue;
        }

        const auto deducedError = DeduceError(uploadError.Code);

        if (
            errorType == CloudSyncError::DataUploadFailed
            && deducedError == CloudSyncError::Network) {
            errorType = deducedError;
        } else if (
            deducedError == CloudSyncError::ProjectStorageLimitReached
            || deducedError == CloudSyncError::Cancelled) {
            errorType = deducedError;
            break;
        }
    }

    UploadFailed({ errorType, {} });
}

void LocalProjectSnapshot::UpdateProjectSnapshot()
{
    auto project = mWeakProject.lock();

    if (project == nullptr) {
        UploadFailed(MakeClientFailure(
                         XO("Project was closed before snapshot was created")));
        return;
    }

    const bool isCloudProject = mProjectCloudExtension.IsCloudProject();
    const bool createNew
        =mUploadMode == UploadMode::CreateNew || !isCloudProject;

    ProjectForm projectForm;

    if (createNew) {
        projectForm.Name = mProjectName;
    } else {
        projectForm.HeadSnapshotId = mProjectCloudExtension.GetSnapshotId();
    }

    // For empty projects, mProjectBlocksLock will be nullptr at this point
    if (mProjectBlocksLock != nullptr) {
        projectForm.Hashes.reserve(mProjectBlocksLock->Blocks.size());
        std::transform(
            mProjectBlocksLock->Blocks.begin(), mProjectBlocksLock->Blocks.end(),
            std::back_inserter(projectForm.Hashes),
            [](const auto& block) { return block.Hash; });
    }

    using namespace audacity::network_manager;

    const auto url = createNew ? mServiceConfig.GetCreateProjectUrl()
                     : mServiceConfig.GetCreateSnapshotUrl(
        mProjectCloudExtension.GetCloudProjectId());

    projectForm.Force = !createNew && mUploadMode == UploadMode::ForceOverwrite;

    auto request = Request(url);

    request.setHeader(
        common_headers::ContentType, common_content_types::ApplicationJson);
    request.setHeader(
        common_headers::Accept, common_content_types::ApplicationJson);
    // request.setHeader(common_headers::ContentEncoding, "gzip");

    SetOptionalHeaders(request);

    const auto language = mServiceConfig.GetAcceptLanguageValue();

    if (!language.empty()) {
        request.setHeader(
            audacity::network_manager::common_headers::AcceptLanguage, language);
    }

    request.setHeader(
        common_headers::Authorization, mOAuthService.GetAccessToken());

    auto serializedForm = Serialize(projectForm);

    auto response = NetworkManager::GetInstance().doPost(
        request, serializedForm.data(), serializedForm.size());

    response->setRequestFinishedCallback(
        [this, response, createNew, weakThis = weak_from_this()](auto)
    {
        auto strongThis = weakThis.lock();
        if (!strongThis) {
            return;
        }

        const auto error = response->getError();

        if (error != NetworkError::NoError) {
            UploadFailed(DeduceUploadError(*response));

            mCreateSnapshotPromise.set_value({});
            return;
        }

        const auto body = response->readAll<std::string>();
        auto result     = DeserializeCreateSnapshotResponse(body);

        if (!result) {
            UploadFailed(MakeClientFailure(
                             XO("Invalid Response: %s").Format(body).Translation()));

            mCreateSnapshotPromise.set_value({});
            return;
        }

        OnSnapshotCreated(*result, createNew);
    });

    mCancellationContext->OnCancelled(response);
}

void LocalProjectSnapshot::OnSnapshotCreated(
    const CreateSnapshotResponse& response, bool newProject)
{
    auto project = mWeakProject.lock();

    if (project == nullptr) {
        UploadFailed(MakeClientFailure(
                         XO("Project was closed before snapshot was created")));
        return;
    }

    if (newProject) {
        mProjectBlocksLock->UpdateProjectHashesInCache();
    }

    mProjectBlocksLock->FillMissingBlocks(response.SyncState.MissingBlocks);

    mProjectCloudExtension.OnSnapshotCreated(*this, response);

    {
        auto lock = std::lock_guard { mCreateSnapshotResponseMutex };
        mCreateSnapshotResponse = response;
    }

    mCreateSnapshotPromise.set_value({ response, shared_from_this() });

    auto projectData = mProjectDataPromise.get_future().get();

    if (mCancelled.load(std::memory_order_acquire)) {
        return;
    }

    StorePendingSnapshot(response, projectData);

    DataUploader::Get().Upload(
        mCancellationContext, mServiceConfig, response.SyncState.FileUrls,
        projectData.ProjectSnapshot,
        [this, weakThis = weak_from_this()](ResponseResult result)
    {
        auto strongThis = weakThis.lock();
        if (!strongThis) {
            return;
        }

        auto& db = CloudProjectsDatabase::Get();

        const auto projectId  = mCreateSnapshotResponse->Project.Id;
        const auto snapshotId = mCreateSnapshotResponse->Snapshot.Id;

        if (result.Code != SyncResultCode::Success) {
            db.RemovePendingSnapshot(projectId, snapshotId);
            db.RemovePendingProjectBlob(projectId, snapshotId);
            db.RemovePendingProjectBlocks(projectId, snapshotId);

            DataUploadFailed(result);
            return;
        }

        mProjectCloudExtension.OnProjectDataUploaded(*this);
        db.RemovePendingProjectBlob(projectId, snapshotId);

        if (mProjectBlocksLock->MissingBlocks.empty()) {
            MarkSnapshotSynced();
            return;
        }

        mMissingBlockUploader = MissingBlocksUploader::Create(
            mCancellationContext, mServiceConfig,
            mProjectBlocksLock->MissingBlocks,
            [this, weakThis = weak_from_this()](
                auto result, auto block, auto uploadResult)
        {
            auto strongThis = weakThis.lock();
            if (!strongThis) {
                return;
            }

            if (!IsUploadRecoverable(uploadResult.Code)) {
                CloudProjectsDatabase::Get().RemovePendingProjectBlock(
                    mCreateSnapshotResponse->Project.Id, block.Id);
            }

            mProjectCloudExtension.OnBlockUploaded(
                *this, block.Hash,
                uploadResult.Code == SyncResultCode::Success);

            const auto completed
                =result.UploadedBlocks == result.TotalBlocks
                  || result.FailedBlocks != 0;
            const bool succeeded = completed && result.FailedBlocks == 0;

            if (!completed) {
                return;
            }

            if (succeeded) {
                MarkSnapshotSynced();
            } else {
                DataUploadFailed(result);
            }
        });
    });
}

void LocalProjectSnapshot::StorePendingSnapshot(
    const CreateSnapshotResponse& response, const ProjectUploadData& projectData)
{
    CloudProjectsDatabase::Get().AddPendingSnapshot(
        { response.Project.Id, response.Snapshot.Id,
          mServiceConfig.GetSnapshotSyncUrl(
              response.Project.Id, response.Snapshot.Id) });

    CloudProjectsDatabase::Get().AddPendingProjectBlob(
        { response.Project.Id, response.Snapshot.Id,
          response.SyncState.FileUrls.UploadUrl,
          response.SyncState.FileUrls.SuccessUrl,
          response.SyncState.FileUrls.FailUrl, projectData.ProjectSnapshot });

    if (mProjectBlocksLock->MissingBlocks.empty()) {
        return;
    }

    std::vector<PendingProjectBlockData> pendingBlocks;
    pendingBlocks.reserve(mProjectBlocksLock->MissingBlocks.size());

    for (const auto& block : mProjectBlocksLock->MissingBlocks) {
        pendingBlocks.push_back(PendingProjectBlockData {
                response.Project.Id, response.Snapshot.Id, block.BlockUrls.UploadUrl,
                block.BlockUrls.SuccessUrl, block.BlockUrls.FailUrl, block.Block.Id,
                static_cast<int>(block.Block.Format), block.Block.Hash });
    }

    CloudProjectsDatabase::Get().AddPendingProjectBlocks(pendingBlocks);
}

void LocalProjectSnapshot::MarkSnapshotSynced()
{
    using namespace network_manager;
    Request request(mServiceConfig.GetSnapshotSyncUrl(
                        mCreateSnapshotResponse->Project.Id,
                        mCreateSnapshotResponse->Snapshot.Id));

    SetCommonHeaders(request);
    SetOptionalHeaders(request);

    auto response = NetworkManager::GetInstance().doPost(request, nullptr, 0);

    response->setRequestFinishedCallback(
        [this, response, weakThis = weak_from_this()](auto)
    {
        auto strongThis = weakThis.lock();
        if (!strongThis) {
            return;
        }

        CloudProjectsDatabase::Get().RemovePendingSnapshot(
            mCreateSnapshotResponse->Project.Id,
            mCreateSnapshotResponse->Snapshot.Id);

        if (response->getError() != NetworkError::NoError) {
            UploadFailed(DeduceUploadError(*response));
            return;
        }

        mCompleted.store(true, std::memory_order_release);
        mProjectCloudExtension.OnSyncCompleted(this, {}, mAudiocomTrace);
    });

    mCancellationContext->OnCancelled(response);
}

void LocalProjectSnapshot::DeleteSnapshot()
{
    if (!mCreateSnapshotResponse) {
        return;
    }

    using namespace network_manager;

    Request request(mServiceConfig.GetDeleteSnapshotUrl(
                        mCreateSnapshotResponse->Project.Id,
                        mCreateSnapshotResponse->Snapshot.Id));

    SetCommonHeaders(request);

    auto response = NetworkManager::GetInstance().doDelete(request);

    response->setRequestFinishedCallback(
        [this, response, strongThis = shared_from_this()](auto)
    {
        CloudProjectsDatabase::Get().RemovePendingSnapshot(
            mCreateSnapshotResponse->Project.Id,
            mCreateSnapshotResponse->Snapshot.Id);
    });
}
} // namespace audacity::cloud::audiocom::sync
